Repository navigation
feat(boards): share boards by role and keep project settings per member - #9797
Conversation
Replace board visibility, is_public and shared_boards with board_members and general_access, read through the query layer. Key projects by project_id across accounts, re-keying any shared id, and add per-member project_workspaces with a document schema floor. Clear an account's board project memberships when the account is deleted.
Add a board access service that grants viewer, contributor, editor and owner actions from membership and general access; routers, graph execution, workflow calls and upload events all ask it. Boards report general_access, my_role and allowed_actions, keep board_visibility as a deprecated alias, and answer 404 when unreadable and 403 when an action is not allowed. Drop the unused general_access index, and document the roles and the API changes.
…o their owner New draft projects mint project-<uuid> ids like every other client path; a create naming another account's id answers 409 and changes nothing. Origin-scoped queue reads (item ids, current, next, status) return a non-admin only their own items, keeping their query plans. Document global project ids and the queue scoping.
Projects read with the caller's workspace, which saves through its own revision-checked route and indexes its media separately. A document schema floor refuses clients that predate the split; deleting a project or account drops every member's workspace references. Document the content and workspace contract.
Layers, reference images, adjustments and workflow items take UUID-based ids from createItemId instead of a timestamp with a short random suffix.
…'s workspace Saves send the content (raising the document floor to 4) and then the caller's workspace, each at its own revision, and loads join the two halves back into one document. Drafts, recovery, conflicts, copies and duplicates track both halves, and the conflict banner names a settings-only conflict.
…settings .invk v3 writes project.json as content beside an optional workspace.json, behind a remembered "Include my settings for this project" option; v2 files still import, and newer schemas are refused from the manifest. Imports and duplicates create the content, then save the creator's settings, warning when that save fails. Groups the project halves module into the shared startup chunk, restoring the editor's request budget.
Boards shared with you list under Shared with me and admins' other-user boards under their own group; menus offer only what your role allows, with Access for owners. Results, uploads and moves never target a board you can only view: they go to the project inbox, and refused or revoked access refreshes the list with a notice.
…ttings in What's New Multi-user servers show a Share boards headline and the note that Public boards are now Everyone can contribute. Every server notes that layout, generation settings and prompt history stay each member's own in a project.
Board drag between projects is offered only on the caller's own boards their role lets them move. A refused archive restores the auto-add board only if the user has not chosen another since. The workspace reference query-plan check runs on SQLite only.
…haring-foundations
A board the caller can no longer open reads as gone when a project loads, as a deleted one does.
Board moves stay gated on the caller's role rather than board visibility: an owner can move a board whatever its general access.
Observe borrow-release wakeups directly and allow session GC to finish on busy runners. Give nested-loop failure cleanup a consistent integration-test deadline.
# Conflicts: # tests/app/services/session_processor/test_lent_device_scheduling.py
lstein
left a comment
There was a problem hiding this comment.
I reviewed this at 83d4a29 by trying to break each area: board authorization, migrations and SQL, the per-member workspace split, and the gallery client. The PR's own tests pass. I ran the migrations on 6.13- and alpha.1-shaped SQLite databases. I had no MySQL/MariaDB server, so I checked the server path by reading the code and compiling its DDL offline. I found two blockers. Both lose or corrupt members' data.
Blockers
1. The lazy split from schema 3 to 4 can permanently wipe a member's workspace
durableSyncedPersistence.ts:1510-1557 (pushHalves) splits a whole (schema-3) project in two separate requests. It first sends the content PUT with minimum_document_schema_version: 4, then sends PUT /projects/{id}/workspace. On the server, ProjectRecordService.update (project_records_default.py:298) replaces projects.data with the content alone. Between the two requests, the server no longer holds the member's layout, generation settings, prompt history, selection or staging.
recordRequiresDocumentUpgrade (:377-380) makes this split happen on the first save after opening, even when nothing changed. No user action is needed to trigger it.
Triggering sequence:
- Open a project saved before this PR.
- The content
PUTcommits. - Before the workspace
PUTlands, the user signs out.endLifetimeabortsowner.signal, soassertOwner()throws before the workspace request is sent.accountOwnedWorkbenchDatabase.ts:51-59then deletes the account's IndexedDB, local draft included. A tab closing, or a private window with no draft store plus a failed request, ends the same way. - Every later load joins the content with
createDefaultProjectWorkspace(). The member's settings are gone.
Before this PR every save was one whole-document transaction, so a save that only half landed could not destroy data already on the server. In the same window, media_references.replace(owner_kind="project") (:307) also drops the references to staged canvas candidates. Those candidates are intermediates, so cleanup can delete them while a surviving draft still names them.
Suggested fix: do the upgrade in one server transaction (content plus the first workspace row in one request), or keep the whole document until the workspace row exists.
2. Losing access to a board is now treated as the board being deleted
_require (routers/_access.py:22-37) now answers 404 for a board the caller can't read. On the base it answered 403, and the old get_board comment explained why: "clients take it as the board being gone, and clear what names it". The client side was not changed:
WorkbenchRuntime.tsx:46-75checks every board a project names (selected board, auto-add board, queue items) throughgalleryBoardExists(gallery/data/backend.ts:1071-1087).galleryBoardExistsmaps a 404 to "doesn't exist".- That runs
reconcileDeletedGalleryBoard(workbenchState.ts:3051-3104).
Triggering sequence:
- A owns board X with general access Everyone can contribute.
- B selects X and generates into it.
- A sets X to Restricted.
- B reopens the project. In B's saved workspace:
selectedBoardIdbecomes'none', so later results go to Uncategorized, not to the project Inbox the PR describes.- The auto-add board is reset.
- Every locally known item on X, including B's own images that are still on X on the server, gets
boardId: 'none'.
None of this is undone if A grants access again. A reversible permission change triggers an irreversible cleanup. Either the server needs a status that separates "gone" from "denied" (as #9394 did for media), or the client must stop treating this 404 as deletion.
Non-blocking
- Admin owners still permanently delete other people's media.
delete_board(boards.py:174) setscascade_user_id = Nonefor any admin. So an admin deleting their own shared board withinclude_images=truehard-deletes other members' uploads instead of returning them to their owners' Uncategorized.admin_overridereportsfalsein that case (board_access_common.py:109), so the UI can't warn.test_delete_board_with_include_images_admin_skips_user_filterasserts this behaviour. project_idleaks through item DTOs._describehidesproject_idon aBoardDTOthe caller doesn't own.ImageDTO.project_id/VideoDTO.project_idare returned unredacted to anyone who can view a project member board shared with them, throughGET /images/i/{name}, the list endpoints and by-names. Combined with the 409 on project create for an existing id, this confirms the project exists.- A revoked or view-only destination is caught only at save time, and nothing refreshes afterwards.
- There is no access-change event, and
refetchOnWindowFocusis false. A cachedadd_mediastays true after a downgrade. - With no listing cached at all (gallery widget closed for more than 5 minutes, or straight after startup),
canAddToCachedGalleryBoardreturnslistings.length === 0(queryCache.ts:480). That lets a view-only board through. - Either way the run fails in
ImagesInterface.saveafter the whole generation. Boards are re-fetched only after a successful result (features/queue/runtime.ts:1054,1125,1244), so every later run fails the same way. - Failed uploads behave the same:
useGalleryUploadAction.ts:160-168returns withoutinvalidateGallery.
- There is no access-change event, and
- Explicit board ids in workflow board fields are never checked.
resolveBoardSentinel(buildGraph.ts:71-81) redirects onlyautoand empty values.BoardInput's option list is cached for the whole session (boardOptionsRequest), so it doesn't follow grants or revocations. - A board created a moment ago can be overwritten by an older listing. A boards fetch started before
createBoardcan land between the optimistic patch andscheduleGalleryInvalidation's board invalidation (queryCache.ts:690-717). Results then go to the Inbox, and no Auto badge is shown. - Whether results reach an archived board depends on cache timing. Whether results go to a hidden archived board depends on whether an include-archived listing is still cached (
queryCache.ts:455-480). - The workspace save ignores the canvas schema floor.
save_workspacechecks only the document floor, andProjectWorkspaceSaveRequesthas nomax_canvas_schema_version. Yet the workspace holds canvas-schema state (stagingArea,selectedLayerId). - The new migrations never run against MySQL/MariaDB in CI. All three
test_migration_2026_10_10_*files callDatabase.open_sqlitedirectly, so they don't get theuses_databasemark. The server branches therefore aren't exercised with data: the duplicate re-key, theDROP PRIMARY KEY, ADD PRIMARY KEYswap, and theshared_boardscopy.test_portable_migrationsruns that DDL on empty tables only. - A share on a board with a NULL owner is dropped.
migration_2026_10_10_board_roles.py:68filters on_shared_boards.c.user_id != _boards.c.user_id, which is NULL whenboards.user_idis NULL.or_(_boards.c.user_id.is_(None), …)fixes it. Only hand-edited databases should hit this. - Re-keyed project documents are re-serialized differently. They go through plain
json.dumps(migration_2026_10_10_global_project_ids.py:79,126). The app usesensure_ascii=Falsewith compact separators (project_records_default.py:42). A document near the size limit can come out over it. - Browser state for a re-keyed id has no migration path. An open tab's unsynced edits, or an IndexedDB draft, under the old id comes back as a "project deleted" conflict. One line in the release notes is probably enough.
- Identity is resolved differently at queue time.
workflow_call_batch._assert_user_can_access_board(:311-332) ignores single-user mode and readsis_adminfrom the demotedsystemuser.BoardsInterface._viewerrefuses a deactivated user but not a deleted one.ImagesInterfaceandVideosInterfacerefuse both. - Single-account board folding is applied in one place only.
asSingleAccountBoardsruns only inuseGalleryBoards. The image context menu (ImageContextMenu.tsx:1073) and the workflowBoardInputshow raw DTOs, so the same board appears as the user's own in the panel and under "Other users' boards" there. - The mock backend has drifted from the server.
mock-backend.mjs:1094,1108,1762still refuses to claim a board whoseboard_visibility !== 'private', and its seeded boards have noboard_visibility. - A workflow id of
__proto__loses itslastRun.projectHalves.ts:83-88writes into a plain object literal. Only a crafted file can do this, and only the run preview is lost.
What held up
- Board authorization:
- Every board, item, project and queue-origin route I traced goes through
board_access. - Moves need AddMedia on the destination plus ownership of the item.
- Removal is decided against the item's current board.
- All delete paths use
ItemAction.Delete. item_action_for_changesfails closed.- The
board_visibilityalias can't be used to escalate, and sending both fields gets 422. - Revocation after enqueue wins at save and read time.
- Socket audiences don't reach outsiders for a Restricted board.
- Every board, item, project and queue-origin route I traced goes through
- Migrations:
- The visibility mapping holds on 6.13- and alpha.1-shaped data.
- Re-keying rewrites every server-side reference I could find: data, boards, images, queue
project_id/origin, media references and client state. - SQLite rolls back cleanly on a mid-migration failure, and each crash point on a server converges on rerun.
- Workspace split:
- Workspaces are isolated per caller.
- Old clients are refused on every write path, including those that send no version.
- Concurrent saves of the two halves reconcile correctly.
- The .invk v2/v3 import edge cases are handled.
…at a revoked board as refused A whole project is split into content and the member's workspace by one request that stores both in one transaction, so a save that half lands can no longer wipe the member's layout, settings and staging. An existing board the caller cannot read answers 403 instead of 404, and the client keeps its references to it; media DTOs carry a project id only for its owner and administrators; an administrator deleting their own board no longer deletes other members' media. Gallery destinations are decided from a fetched board listing on every submit, upload and canvas save, workflow board fields follow revocations, and the new migrations run on every CI database.
…'s workspace A save that raises the document floor across the split without a workspace answers 400 split_needs_workspace, so no client can leave split content stored beside no workspace row. The mock backend mirrors both split refusals.
A split project created at floor 4 holds no workspace row until the creator's first workspace save; the guard only keeps the split of a whole project from landing without the member's workspace.
…eat/sharing-foundations
lstein
left a comment
There was a problem hiding this comment.
Second round, at 32880d7 (fixes 492c1be, 21b03e1, 06af3ff). Thanks: both blockers from the last review are fixed, and so are most of the non-blockers. Two small backend issues are left before this can merge. One was missed by the fix; the other was introduced by it. I've also listed some follow-ups that don't need to block.
Previous blockers: fixed
- Split atomicity.
update()now writes the content, the board rename, theprojectmedia references, the member's workspace and theproject_workspacereferences in one transaction. I forced a workspace conflict on a split and the revision, floor (3) and references were left exactly as before. Staged media moves fromprojecttoproject_workspacein the same commit. Thesplit_needs_workspaceguard refuses every bypass I tried, and none of this client's paths can trigger it. Lost answers, reloads, sign-out mid-request and unload-journal replay all go through the combined request and reconcile correctly. - Revoked board treated as deleted.
_requireanswers 403 for a board that exists but can't be read, and 404 only onBoardRecordNotFoundException. The probe is a raw column read, so a corrupt row gives a 500, never a "gone".galleryBoardExistsreturns true on 403, so no reconcile runs. Results go to the Inbox, the saved state keeps the board, and a regrant restores both the selection and the destination.
Also fixed:
- An admin who owns a board no longer deletes other members' media.
- The workspace save checks the canvas floor.
- Explicit board ids in workflow fields are checked, and the field's board options follow the shared listing.
queue_viewerhandles identity consistently.- The new migrations run on the MySQL and MariaDB jobs (860 passed at this head).
- The NULL-owner share copy is fixed.
- Re-keyed documents are serialized the way the app serializes them.
- The mock backend matches the server.
- The
__proto__lastRuns case is gone.
Changes requested
1. Video recall routes still return another user's project_id
_place_gallery_video (invokeai/app/api/routers/video_recall.py:578-590) calls assert_video_read_access, then videos.get_dto, and returns the DTO without redact_project_ids. This is the only router that returns a media DTO without redacting it. I checked every get_dto call in routers/.
Trigger:
- user1 has a video in one of their projects, on a board with Everyone can view.
- user2 calls
GET /api/v1/videos/i/{name}and getsproject_id: null. - user2 calls
POST /api/v1/recall/video/default/initial-video?video_name={name}. The response'svideo.project_idis user1's project id./reference-videoand/conditioning-videodo the same.
This contradicts the API guide ("everyone else reads null"). Combined with the 409 on project create, it confirms the project exists. Fix: redact_project_ids([video], current_user) in _place_gallery_video, plus a test next to the existing redaction tests. The upload variants return the caller's own media and are fine.
2. The project-id migration can now fail on a client-state value, and the app won't start
migration_2026_10_10_global_project_ids.py:50 now serializes with ensure_ascii=False. In the client-state rewrite (:121-132), json.loads turns an escaped lone surrogate (e.g. "\ud83d") into a lone surrogate character. JSON.stringify writes exactly that for a string cut mid-emoji. _dumps then writes the character out raw. The UTF-8 encode fails at conn.execute, which is outside the try/except ValueError:
MigrationError: 'utf-8' codec can't encode character '\ud83d' ... surrogates not allowed
Trigger: two accounts hold the same project id (so _rekey runs), and the re-keyed account has a client_state value that contains the old id and such an escape. I reproduced it with the PR's own before fixture. With the previous json.dumps (ASCII escapes), the same input passes.
Project documents aren't affected, because _serialize_project_document already refuses lone surrogates on write. Only client_state stores the client's raw string. Duplicate ids are rare, but when this happens startup is blocked. Fix: encode inside the try (rewritten_value.encode("utf-8")) and skip the row on failure, or keep ensure_ascii=True for client state, which has no size limit to protect. A test with a surrogate escape would pin it.
Recommended follow-ups (non-blocking)
- A failed board-list refetch sends results to the Inbox without telling the user.
- Where:
canAddToCachedGalleryBoard(features/gallery/data/queryCache.ts:464-484) filters onquery.state.status === 'success'. - Why it happens: when a refetch fails, TanStack Query keeps the data but sets
status: 'error', so the only listing drops out and the function returnsfalse. This contradicts the comment inensureGalleryBoardDestinations("the cached listings, if any, decide"). - Trigger: with show archived on, the panel's listing is also the ensured one. One of its five requests (
/boardsor an Uncategorized count) returns 500. - Effect: every submit then goes to the Inbox. The panel still shows the chosen board and no redirect badge.
- Suggestion: accept listings whose
datais defined whatever theirstatus. Add a test that seeds the include-archived key in an error state with data.
- Where:
- Create paths still store content and the creator's workspace in two requests.
- Where:
createSaveDraftAsNew(durableSyncedPersistence.ts:146-167), the first save of a new project (the!basebranch ofpushHalves), and import/duplicate viasaveTransferredWorkspace. - Effect: imports and duplicates can be redone from their source. A conflict copy ("save mine as a copy") and a new project's first save have no other copy. Signing out between the two requests wipes IndexedDB and loses the draft workspace for good.
- This is the window this round closed for whole projects. Suggestion: let
POST /projectstake an optionalworkspaceand write it in the same transaction.
- Where:
- Some destinations still skip the ensure step or don't refresh after a refusal.
- Video reference uploads read the upload board synchronously from the observed listing (
app/VideoUiAdapter.tsx:100-105,VideoReferenceListField.tsx:562-582). Nothing refreshes that listing except invalidation, andtouchGalleryImagesruns only on success, so a revoked auto-add board 403s on every retry. - A refused canvas save to gallery (
useCanvasGallerySave.ts:63-70) doesn't invalidate, and the next save within 60 s reuses the fresh listing. queue/runtime.ts:440-450refreshes after a failed run only fordestination === 'gallery', so a canvas-bound workflow that names a board isn't covered.useGalleryUploadAction.ts:99still trusts an unlisted id "as given".- Suggestion: route these through
ensureGalleryBoardDestinationsand invalidate on any board refusal.
- Video reference uploads read the upload board synchronously from the observed listing (
- Cost of the pre-submit listing.
- Waiting at submit: after 60 s without an invalidation, every submit awaits a full listing (five requests) before dispatch (
invocationSubmit.ts:205-208, 283-290). The preparation lease silently drops extra Invoke clicks in that window. - Extra request per completion: the always-mounted include-archived
useQueryinVideoUiAdapteradds a second listing to everyinvalidateGallery. - Not measured: the browser baseline counts only initial asset requests, so neither cost is recorded.
- Suggestion: measure it on the release gate. Consider resolving the destination optimistically from a cached listing and refreshing in the background, or queueing clicks that arrive during preparation instead of dropping them.
- Waiting at submit: after 60 s without an invalidation, every submit awaits a full listing (five requests) before dispatch (
- Small items.
PasteMediaDialog.tsx:116-125passes the unfolded listing, so a single-account install labels legacy boards by their stored names.useGalleryActions.ts:33,72tells "lost" from "stale" by matching the server's detail string. A structuredcodein the 403 body would be sturdier.PUT /projects/{id}/workspaceaccepts a workspace beside a whole (floor-3) project, whichupdate()would refuse asworkspace_needs_split_document. Harmless with this client, but the two routes should agree.- Two small inconsistencies remain in the image/video interfaces and
workflow_call_batch:ImagesInterface/VideosInterfacestill inline the deleted/deactivated rule instead of usingqueue_viewer, although the docstring says everything resolves through it.users.getraising inworkflow_call_batchnow escapes as a raw exception, notUnsupportedWorkflowNodeError. It still fails closed.
Checks
- SQLite:
tests/app/services/project_records,tests/app/routers/test_projects.py, the multiuser board/video/authorization suites,board_access,test_workflow_call_batch, the invocation-context board tests and the wholesqlite_migratordirectory: 152 + 558 passed. - MySQL and MariaDB: CI's jobs passed at this head (860 passed, 2 skipped). I had no server to run them locally.
- Frontend: findings come from reading the code, plus a probe against the installed TanStack Query 5.102.8 confirming that data is kept on a failed refetch.
Video recall routes blank foreign project ids like the videos API; the project-id migration writes client state with ASCII escapes so a lone surrogate no longer blocks startup. A workspace saved beside a whole project is refused as on the content route, a board listing whose refetch failed still decides destinations, and the paste dialog folds boards like the gallery.
lstein
left a comment
There was a problem hiding this comment.
Third round, at a818f71 (fix e24b1ed). Thanks. Both remaining blockers are fixed, and I found nothing new that blocks. Approving.
Previous blockers: fixed
- Video recall leaked
project_id._place_gallery_videonow callsredact_project_idsbefore it returns or emits the DTO, so/initial-video,/reference-videoand/conditioning-videonow match the videos API. The new test fails when the redaction is reverted. - Migration crash on a lone surrogate. Client state is now rewritten with ASCII escapes (
_dumps_client_state). The output is pure ASCII, so the encode atconn.executecan't fail. Project documents keep the compact form, which is safe because their writes already refuse lone surrogates. The re-key test now includes a surrogate escape and fails when the old serializer is put back.
Also fixed from the follow-ups
canAddToCachedGalleryBoardnow counts a listing that holds data even when its refetch failed. This has a test.PasteMediaDialogfolds boards the same way the gallery does.PUT /projects/{id}/workspacenow refuses a whole (floor < 4) project withworkspace_needs_split_document, asupdate()does. I checked that this client can't hit that refusal: whole creates send no workspace, and whole updates return before the workspace save.ImagesInterface/VideosInterfacenow resolve the queue user through_viewer/queue_viewer.
Possible follow-up issues (non-blocking)
- Create paths still store content and the creator's workspace in two requests. This affects
createSaveDraftAsNew(conflict copy), the first save of a new project (the!basebranch ofpushHalves), and import/duplicate viasaveTransferredWorkspace. If the user signs out between the two requests, the conflict copy's or new project's draft workspace is lost for good. Imports and duplicates can be redone from their source. Suggestion: letPOST /projectstake an optionalworkspaceand write it in the same transaction. - Some board destinations skip the ensure step or don't refresh after a refusal.
- Video reference uploads (
VideoUiAdapter.tsx,VideoReferenceListField.tsx) read the board from the observed listing, so a revoked auto-add board answers 403 on every retry. - A refused canvas save to gallery (
useCanvasGallerySave.ts) doesn't invalidate the listing. queue/runtime.tsrefreshes after a failed run only fordestination === 'gallery'.useGalleryUploadAction.tstrusts an unlisted id as given.- Suggestion: route these through
ensureGalleryBoardDestinationsand invalidate on any board refusal.
- Video reference uploads (
- Cost of the pre-submit listing. After 60 s without an invalidation, each submit waits for a full listing (five requests), and the preparation lease drops extra Invoke clicks during that wait. The always-mounted include-archived query in
VideoUiAdapteradds a second listing on everyinvalidateGallery. Neither cost is measured. Suggestion: measure it on the release gate, and consider resolving the destination optimistically with a background refresh, or queueing the dropped clicks. - Small items.
useGalleryActions.tstells "lost" from "stale" by matching the server's detail string. A structuredcodein the 403 body would be sturdier.users.getraising inworkflow_call_batchescapes as a raw exception rather thanUnsupportedWorkflowNodeError. It still fails closed.
Checks
- SQLite: video recall, projects, boards/projects,
project_records, the wholesqlite_migratordirectory, and the invocation-context image/video tests: 478 passed, 6 skipped. - Mutation check: reverting either blocker fix fails its new test.
- CI at this head: 20 passed, 1 skipped, including the MySQL and MariaDB jobs.
- Frontend changes were checked by reading the code and by CI only.
…d destinations after refusals POST /projects stores the creator's workspace in the content's transaction; every client create path sends it, and imports and duplicates stage the inbox first so it is named. Board destinations resolve from any cached listing with a background refetch, refusals refetch that listing at once, uploads refuse boards the listing rejects, and board 403s carry a structured code.
…ale growth Every route loads the English catalog, which board sharing grows by about 4 KB of used strings; request counts and source owners are unchanged.
|
Follow-up on 895e104 and e18983f. My approval stands: I found no blockers. Thanks for taking on the follow-ups. Here's what I checked and what I'd suggest next. Checked
Possible follow-up issues (non-blocking)
|
Re-records the browser byte baselines for the merged tree: main's hint and recall additions plus board sharing's locale growth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-records the editor bundle baseline for the merged tree, adding board sharing's eager sources. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Lays the foundations for sharing in 7.0, so that later collaboration work (members, share dialog, project grants, locks) does not need another data migration.
Board roles. Every board has an owner and a role per caller: Viewer, Contributor, Editor or Owner, with administrators overriding.
board_accessservice decides every board and item action. Routers, graph execution, workflow calls and upload events all use it.403, as an image or video they cannot read does, so a revoked grant is never mistaken for a deleted board; only a missing board answers404. An action their role does not allow also answers403.board_visibilityandshared_boardsbecomegeneral_access(Restricted / Everyone can view / Everyone can contribute) plus aboard_memberstable.BoardDTOreportsmy_role,allowed_actions,admin_overrideandgeneral_access.project_idonly for administrators and for the project's own owner.Global project ids. Project ids are unique across accounts. Queue reads by origin are scoped to the caller.
Per-member project settings. A project's content (name, canvas, workflows) is saved apart from each member's workspace (layout, widgets, generation settings, prompt history, selection, staging):
project_workspacestable andPUT /projects/{id}/workspacePUT /projects/{id}carrying the content, the raised document floor and the member's workspace, stored in one transaction, so a save that half lands cannot lose the member's settings.invkv3:project.jsonis the content, with an optionalworkspace.jsonbehind Include my settings for this projectGallery board UI:
What's New: a "Share boards" headline (multi-user only) and notes on Public boards and per-member settings.
Builds on #9713.
Related Issues / Discussions
Fixes the board half of #9673. Workflow sharing is still listed under known bugs.
QA Instructions
Ran on this branch:
Follow-up at 492c1be:
pnpm check:releasepasses (lint, 10292 unit and 2925 browser tests, fixtures, architecture performance, project-file journeys, accessibility);pytest tests/app tests/fixtures -n 4: 8657 passed, 34 skipped, 5 xfailed;openapi.jsonandschema.tsregenerated; the shared API package's lint and tests pass. Verified in Chromium against the mock backend: revoking a member's access to their selected board moves the view to the project Inbox without rewriting the saved selection, which returns when access is granted again; the Change Board submenu and the workflow board field show the same folded listing as the panel. The three new migration tests now take the shared database fixture, so CI runs them on MySQL and MariaDB; only SQLite was available here.First round:
pnpm -C invokeai/frontend/webv2 check:release:ToastContrast(untouched here), can time out on a hover under a heavy full run; it passes 3/3 on its own.uv tool run ruff@0.11.2 check/format --checkon the 85 changed Python files: clean.uv run --no-sync python -m pytest -n 4: 13406 passed, 102 failed, 193 skipped. Each failure was traced to this machine, not this branch:tests/model_identification: the Git LFS fixtures are not fetched here.test_app_info.py(busy database): they fail only whilewebv2/distis built, because the UI's catch-all mount answers first. With the build moved aside, the file passes 36/36.test_16_channel_vae_loader,minimax_h3/test_text_encoder_checkpoint) fail the same way on feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's branch.test_directory_install_rollback_race_preserves_both_source_artifacts) depends on the checkout filesystem's directory order. It passes 2/2 from a worktree of this branch, as on feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's.test_ministral3_encoder_config,test_mistral_encoder_checkpoint_loader,test_ideogram4_diffusers_loader,test_migration_loader) ran out of/tmpquota here and pass when rerun.test_model_cache_ram_budget.py, caused by running with-p no:warnings; the file passes without it.No failing test is in a file this branch changes.
MySQL and MariaDB run the same suite in CI; the one query-plan check (SQLite's
EXPLAIN QUERY PLAN) is marked SQLite-only.Each migration has its own tests; board roles are migrated from 6.13- and alpha.1-shaped data (
test_migration_2026_10_10_{board_roles,global_project_ids,project_workspaces}.py).To try sharing in the browser against the mock backend:
node --input-type=module -e "import { startMockBackend } from './scripts/mock-backend.mjs'; await startMockBackend(9191, { profile: 'representative' });"frominvokeai/frontend/webv2INVOKEAI_DEV_BACKEND=http://127.0.0.1:9191 pnpm exec vite dev --port 5273curl -X POST -H 'content-type: application/json' -d '{"intermediatesCaller":"user","sharedBoards":true}' http://127.0.0.1:9191/__faults"multiuser-admin"instead of"user"gives the administrator view.POST /__board-accessrevokes access behind the page's back.Verified in the browser this way:
Review
Second review: a sign-out between the two requests of the lazy split could permanently wipe a member's workspace (the split is now one server transaction); losing access to a board was treated as the board being deleted (an unreadable board now answers
403, and the client keeps its references). Non-blocking findings fixed: an administrator deleting their own shared board hard-deleted other members' media;project_idleaked through image and video DTOs; a revoked or view-only destination was caught only at save time (destinations are decided from a fetched board listing on every submit, upload and canvas save, and failed runs and uploads refresh the listing); explicit board ids in workflow fields were never checked and the board field's options were cached for the session; a board created a moment ago could be overwritten by an older listing; whether results reached an archived board depended on cache timing; the workspace save ignored the canvas schema floor; the new migrations never ran against MySQL/MariaDB in CI; a share on a board with a NULL owner was dropped; re-keyed documents were re-serialized differently; identity was resolved differently at queue time; single-account board folding was applied in one place only; the mock backend had drifted; a workflow id of__proto__lost itslastRun. The upgrade note about re-keyed project ids in open tabs is in the admin guide.First review: material findings were fixed before merge. Among them:
Remaining limits:
Compatibility / Rollout
Migrations: three linear migrations, after feat(boards): boards belong to projects, with a Library tier and whole-project transfer #9713's:
general_access,board_members;board_visibility/shared_boardsdropped)boards.project_id)project_workspacesplusminimum_document_schema_versionPrivate boards become Restricted, shared boards Everyone can view, and public boards Everyone can contribute.
API:
board_visibilityis kept as a deprecated alias derived fromgeneral_accessuntil 8.0; sending both is a422.board_visibility,shared_user_idsandboard_owner_id.project_idon boards, images and videos is returned only to owners and administrators.403(Not authorized to access this board);404means the board does not exist.PUT /projects/{id}takes an optionalworkspacefor the save that splits a whole project, and answersProjectWithWorkspaceDTO; aworkspaceon a save that leaves the document floor below 4 is400workspace_needs_split_document.PUT /projects/{id}/workspacetakesmax_canvas_schema_version.openapi.jsonandschema.tsare regenerated.Clients:
412 document_schema_unsupported) and refuse.invkv3 files.Docs: the multi-user user and admin guides ("Upgrading to 7.0"), the projects and gallery guides, the API guide, the database-layer guide and known bugs are updated.
Checklist
What's Newcopy (if doing a release after this PR)